Skip to content

fix(components): ui:grid renders the 2xl breakpoint its columns map accepts - #8435

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-7097-grid-2xl-breakpoint
Sep 7, 2026
Merged

fix(components): ui:grid renders the 2xl breakpoint its columns map accepts#8435
os-justin merged 4 commits into
mainfrom
claude/issue-7097-grid-2xl-breakpoint

Conversation

@os-justin

@os-justin os-justin commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7097

ui:grid renders the 2xl breakpoint its columns map has always accepted.

Re-measured on today's tree — two of the card's numbers no longer hold

The card was written against @objectstack/spec 17.2.0. This repo now pins 17.3.0, and that changes the accept surface it names.

the card says today, base 34510e0c3
accept surface is BreakpointColumnMapSchema in @objectstack/spec/ui, a strict six-key object gone. objectstack#11027 (ADR-0049 D2) retired the whole ui/responsive vocabulary. node -e "require('@objectstack/spec/ui')" exports exactly one Breakpoint*/Responsive* symbol: ResponsiveStylesSchema. The removal note is in the package's own src/ui/responsive.zod.ts.
"the spec accepts six breakpoints" the accept surface is objectui's own GridSchema.columns, typed number or an open string-keyed record of numbers (packages/types/src/layout.ts:385, mirrored in zod/layout.zod.ts:223 as z.record(z.string(), z.number())). Not six — unbounded.
"the grid renderer reads five" still exactly right. grid.tsx had five read arms and five class maps, xs through xl.
the three rendered-class rows all three reproduced verbatim through a real SchemaRenderer render (see below).

The six-member vocabulary is still real, just no longer spec-side: BreakpointName (packages/types/src/mobile.ts:69), BREAKPOINTS / BREAKPOINT_ORDER (packages/mobile/src/breakpoints.ts, pinned at breakpoints.test.ts:31), and BreakpointColumnMap (packages/layout/src/ResponsiveGrid.tsx:53, re-homed from the retired schema by objectui#7580, maintainer ruling 2026-09-04 option A). So the card's conclusion survives its premise: the vocabulary is six everywhere, and only this consumer stopped at five.

Which layer dropped it — the load-bearing measurement

Both, and that decides the diff. grid.tsx had no 2xl read arm and no GRID_COLS_2XL static class map. Adding the read arm alone would have emitted a class name Tailwind never compiles — v4 finds utilities by scanning source text, so a 2xl:grid-cols- count assembled from a template is not a utility that exists. Green in a unit test, unstyled in the browser.

Measured by compiling packages/components/src/index.css through the package's own pipeline (postcss + @tailwindcss/postcss, the same two lines as packages/components/scripts/build-css.mjs), varying only grid.tsx:

grid.tsx on disk compiled 2xl:grid-cols-* rules control: xl:grid-cols-* rules
base 34510e0c3 blob 11835179a (no map, no arm) 0 12
this branch blob 62b735f9c (map + arm) 12 12

Escape-form note, because it is a trap: Tailwind emits these selectors as .\32 xl\:grid-cols-6, not .2xl\:grid-cols-6 — a CSS identifier cannot start with a digit. Grepping the literal spelling returns zero on a stylesheet that contains all twelve.

The class then survives to the DOM. Emitted className on the rendered div, read through SchemaRenderer:

authored columns before after
{ xs: 1, "2xl": 6 } grid grid-cols-1 gap-4 grid grid-cols-1 2xl:grid-cols-6 gap-4
{ xs: 1, xl: 5 } grid grid-cols-1 xl:grid-cols-5 gap-4 unchanged
4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 unchanged

Not a deliberate five. The escalation fence does not fire: 2xl has a Tailwind equivalent in this setup (default theme, no --breakpoint-* override in any @theme block, so 2xl is 96rem/1536px — the same 1536 BREAKPOINTS['2xl'] carries), and @object-ui/layout's ResponsiveGrid already emits 2xl:grid-cols-* from its own class table. One consumer in this repo shipped the sixth tier; the other did not.

Sibling responsive readers — the triage's "before closing" step

Swept, and there is no second instance. grid.tsx:87 is the only site in packages/** or apps/** that reads .xl off a responsive object. The other two consumers of the vocabulary are keyed by BREAKPOINT_ORDER or by Object.entries, so they carry all six by construction: resolveResponsiveValue / ResponsiveContainer (@object-ui/mobile) and resolveColumnClasses (@object-ui/layout).

What the pin observes, and why that is sound

packages/components/src/__tests__/grid-breakpoint-columns-7097.test.tsx asserts the whole emitted class string, not a parse and not getComputedStyle. happy-dom does not compile Tailwind or evaluate @media (width >= 96rem) as a browser does, so a computed grid-template-columns here would measure the harness. The class string is the renderer's entire output on this path — grid.tsx reads no window, no matchMedia, no ResizeObserver — and one case pins that width-independence explicitly by rendering the same node at innerWidth 375 and 1600 and requiring identical output, rather than being green by accident on happy-dom's default desktop width.

The class reader asserts its element with a message before reading className: a renderer that produced nothing would otherwise throw Cannot read properties of null before any expect ran, and the summary would name a TypeError instead of the node that failed to render (commit 4).

Would an implementation strictly worse than the bug pass it? No. Every case is whole-string equality, so the other five tiers are non-regression assertions of the same file: a change that emits the 2xl class and drops xl reddens, and so does "delete the feature" (no responsive classes at all), which fails all eleven cases.

The case list is derived, not typed out: ALL_BREAKPOINTS is checked against BreakpointName by an _AssertNever alias, so a seventh member of the vocabulary makes this file a compile error instead of silently leaving the new tier untested. That is the durable half of the card, scoped to what still exists — with the spec-side Breakpoint*Map schemas retired, a repo-wide "compare a strict breakpoint map's member set against its renderer's reads" gate has no remaining subject.

Prove the pin can fail — three ablations, restored by state

Each from the committed implementation, with a trap ... EXIT INT TERM and absolute paths; each restore verified by git hash-object equality and an empty git diff HEAD, never by an exit code.

  1. Read site. Deleted only the cn() arm, leaving the class map, so the read layer is isolated. HEAD blob 62b735f9c -> on-disk eede06b1c. 4 failed | 7 passed, red rows by name: a fully authored six-breakpoint map emits all six classes, in order · the reported node — { xs: 1, "2xl": 6 } — now emits its 2xl class · a map naming only 2xl emits that tier and no other tier · renders identically at a phone width and at a 2xl desktop width.
  2. Class-map layer. Kept the read arm, replaced the twelve literals with a runtime template. On-disk ed665c2e0. The DOM pin goes to 1 failed | 10 passed — it catches this only incidentally, through the out-of-range case, while the compiled stylesheet loses all twelve rules. Stated plainly: the DOM pin alone does not cover the class-map layer for in-range counts. The table above is what covers it, and it is why the diff adds the map rather than a template.
  3. Exhaustiveness gate. Dropped '2xl' from ALL_BREAKPOINTS; type-check exits 2 with grid-breakpoint-columns-7097.test.tsx(113,3): error TS2344: Type '"2xl"' does not satisfy the constraint 'never'. The gate's first spelling was inert (const x: Uncovered[] = [] type-checks whether Uncovered is never or '2xl'); commit 2 replaces it and this ablation is what caught that.

Verification

  • pnpm --filter @object-ui/components testTest Files 241 passed (241), Tests 2235 passed (2235).
  • pnpm --filter @object-ui/components type-check — exit 0, both programs (tsc --noEmit && tsc -p tsconfig.test.json, so the exhaustiveness gate is covered).
  • pnpm --workspace-concurrency=2 --filter '@object-ui/components^...' build — exit 0, run first so type-check reads fresh .d.ts.
  • pnpm exec eslint . in packages/components — exit 0, 0 errors. The one warning on grid.tsx is the pre-existing [key: string]: any on the register signature, present at base. The new test file draws none.
  • node scripts/check-changeset-presence.mjs2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7097-grid-2xl-breakpoint.md.
  • node scripts/check-changeset-no-major.mjsNo changeset declares a major bump.
  • node scripts/check-governed-queue-guard.mjs --test on the three changed paths — NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched.
  • check:control-bytes, check:unreferenced-sources, check:self-import, check:esm-specifiers, check:vi-mock-specifiers — all exit 0.
  • check:sdui-registration-pins — exit 0: All 16 registration(s) a sideEffects array promises are present in the built console (518 chunks weighed; the 3 ruled control(s) are in the derived set). It first exited 2 with a prerequisite message (it reads the console bundle at BUNDLE time); pnpm --workspace-concurrency=2 --filter '@object-ui/console...' build — exit 0 — supplied it, so this is measured, not narrowed.

No authored node in any corpus changes: grep -rn '"2xl"' across every JSON/YAML in the tree returns zero hits (control: "type": "grid" hits five catalog files). The fix is additive for future authors and explains why nobody noticed.

Two findings, reported not filed

search_issues returned API rate limit already exceeded for user ID 323634890 on the second query, and the first query's zero-result read has no lit control to certify it, so per the dispatch contract these are reported in full rather than filed unsearched.

  1. packages/components/src/index.css scans its own test files into the published stylesheet. Its @source '../src/**/*.{ts,tsx}' carries no exclusion, while the siblings do — packages/plugin-kanban/src/index.css:91-92 has @source not './**/*.test.{ts,tsx}' and @source not './**/__tests__/**', and packages/fields/src/index.css:44 has the first of those. Two consequences. Bloat is the mild one. The sharp one is that a class literal written as a test's expected value compiles a real utility into dist/index.css, so a test can make the production utility it is checking for exist — measured live here: with this PR's pin file on disk and base grid.tsx, the stylesheet contains .\32 xl\:grid-cols-6 and nothing else in the family, sourced entirely from the test's own assertion strings. The table in this PR is honest only because the measurement was re-run with the pin file removed. Fix is two lines copied from the siblings; it changes published bundle contents, so it wants its own PR and changeset.
  2. GridSchema.columns accepts any string key, not six. packages/types/src/layout.ts:385 types it number or an open string-keyed record of numbers, mirrored as z.record(z.string(), z.number()). columns: { banana: 3 } type-checks, parses, emits nothing — the same silent-drop shape as this card, one level more general. Narrowing to a partial record keyed by BreakpointName would close it, but that is a narrowing of a published accept surface across @object-ui/types plus its zod mirror and the zod-mirror-parity pin, so it wants a ruling rather than a patch. Related and minor: the GridSchema.columns docblock example still reads { xs: 1, sm: 2, md: 3, lg: 4 }, four of six — illustrative rather than wrong, and it sits under layout-default-jsdoc-7361.test.ts, so it was left alone.

Out of scope, deliberately

The designer's legacy flat channel (smColumnsxlColumns) gains no sixth member. Naming it and declaring a sixth designer input is a product surface decision, and this card is about the columns breakpoint object. A comment at the mobile-first ramp records why xxlCols is absent from that condition, so it does not read as an oversight.


Generated by Claude Code

…ccepts

`columns: { xs: 1, '2xl': 6 }` type-checked, passed GridSchema's zod mirror,
emitted no class, and rendered at the `xs` count on every screen. `grid.tsx`
had neither a `2xl` read arm nor a `GRID_COLS_2XL` static class map — both
layers are added, because the read arm alone would emit a class name Tailwind
never compiles (v4 finds utilities by scanning source text, so a
`2xl:grid-cols-${n}` built at runtime is not a utility that exists).

`2xl` is a full member of the breakpoint vocabulary everywhere else in the
repo: `BreakpointName` (@object-ui/types), `BREAKPOINTS` / `BREAKPOINT_ORDER`
(@object-ui/mobile), and `BreakpointColumnMap` (@object-ui/layout), whose
`ResponsiveGrid` already emits `2xl:grid-cols-*`. Only this consumer stopped
at five.

The pin asserts the whole emitted class string for every tier, so the other
five are non-regression assertions of the same file, and derives its case list
from `BreakpointName` with a compile-time exhaustiveness gate — a seventh tier
cannot be added to the vocabulary without this file failing to compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…able to fail

The first spelling was inert: `const _uncovered: Uncovered[] = []` type-checks
whether `Uncovered` is `never` or `'2xl'`, so a seventh `BreakpointName` would
have slipped through the gate meant to catch it. Replaced with
`_AssertNever<T extends never>`, which reddens — verified by deleting a member
from `ALL_BREAKPOINTS` and observing tsc name this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
0 to 12 compiled `2xl:grid-cols-*` rules attributable to grid.tsx, with the
twelve `xl:grid-cols-*` rules as the unchanged control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…peError

`container.firstElementChild.className` on a renderer that produced nothing
throws before any `expect` runs, so the summary would name a TypeError rather
than the node that failed to render. Assert the element with a message first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3472.8 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-DVPOGYaS.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.87KB 114.10KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 189.15KB 52.56KB
fields (index.js) 243.15KB 61.40KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.52KB 46.34KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.68KB 63.94KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.38KB 15.72KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-justin
os-justin marked this pull request as ready for review September 7, 2026 23:22
@os-justin
os-justin added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 3cab570 Sep 7, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-7097-grid-2xl-breakpoint branch September 7, 2026 23:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

columns: { "2xl": N } parses and is then silently dropped — the spec accepts six breakpoints, the grid renderer reads five

2 participants